page.tsx 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944
  1. "use client";
  2. import { Link } from "@/i18n/navigation";
  3. import { useEffect, useMemo, useState } from "react";
  4. import { fetchWalletBalance } from "@/lib/account-api";
  5. import {
  6. fetchSavedWithdrawAccounts,
  7. fetchWithdrawBankOptions,
  8. fetchWithdrawChannels,
  9. submitWithdrawApply,
  10. type SavedWithdrawAccount,
  11. type WithdrawBankOption,
  12. type WithdrawChannel,
  13. } from "@/lib/withdrawal-api";
  14. import { InlineLoading } from "@/components/ui/loading-state";
  15. import { ModalShell } from "@/components/ui/modal-shell";
  16. function channelGroupLabel(channel: WithdrawChannel): string {
  17. const type = channel.type;
  18. const code = (channel.code || "").toUpperCase();
  19. const name = `${channel.name || ""} ${channel.enName || ""}`.toUpperCase();
  20. const aliHint = code.includes("ALI") || code.includes("ALIPAY") || name.includes("ALIPAY");
  21. if (type === "BANK_TELEGRAPHIC") return "国际转账";
  22. if (type === "BANK") return "网银支付";
  23. if (type === "DIGITAL_CURRENCY") return "数字货币";
  24. if (type === "CHANNEL_TYPE_WALLET") return "电子钱包";
  25. if (type === "CHANNEL_TYPE_CARD") return "信用卡";
  26. if (type === "CHANNEL_TYPE_ALI_WALLET" || aliHint) return "支付宝";
  27. if (type === "UCARD_WALLET") return "电子卡";
  28. return "其他";
  29. }
  30. function groupOrder(label: string): number {
  31. if (label === "数字货币") return 1;
  32. if (label === "网银支付") return 2;
  33. if (label === "国际转账") return 3;
  34. if (label === "电子钱包") return 4;
  35. if (label === "电子卡") return 5;
  36. if (label === "支付宝") return 6;
  37. return 99;
  38. }
  39. function formatAmountRange(item: WithdrawChannel): string {
  40. const min = item.minAmount || 0;
  41. const max = item.maxAmount > 0 ? item.maxAmount : "-";
  42. return `$${min} - $${max} ${item.currency || "USD"}`;
  43. }
  44. function formatFee(item: WithdrawChannel): string {
  45. if (item.feeType === 1) return `${item.free ?? 0}%`;
  46. if (item.feeType === 2) return `$${item.feeAmount ?? 0}`;
  47. if (item.free !== null && item.free !== undefined) return `${item.free}%`;
  48. return "0%";
  49. }
  50. function sanitizeHtml(input: string): string {
  51. if (!input) return "";
  52. return input
  53. .replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "")
  54. .replace(/\son\w+="[^"]*"/gi, "")
  55. .replace(/\son\w+='[^']*'/gi, "");
  56. }
  57. function isWalletType(type: string): boolean {
  58. return type === "CHANNEL_TYPE_WALLET" || type === "CHANNEL_TYPE_ALI_WALLET";
  59. }
  60. function isBankType(type: string): boolean {
  61. return type === "BANK";
  62. }
  63. function isCardType(type: string): boolean {
  64. return type === "CHANNEL_TYPE_CARD";
  65. }
  66. function needsSavedAccount(type: string): boolean {
  67. return (
  68. type === "BANK" ||
  69. type === "BANK_TELEGRAPHIC" ||
  70. type === "CHANNEL_TYPE_CARD" ||
  71. type === "DIGITAL_CURRENCY"
  72. );
  73. }
  74. function savedAccountType(type: string): number | null {
  75. if (type === "BANK") return 1;
  76. if (type === "BANK_TELEGRAPHIC") return 2;
  77. if (type === "CHANNEL_TYPE_CARD") return 3;
  78. if (type === "DIGITAL_CURRENCY") return 4;
  79. return null;
  80. }
  81. export default function WithdrawApplyPage() {
  82. const [channels, setChannels] = useState<WithdrawChannel[]>([]);
  83. const [channelsLoading, setChannelsLoading] = useState(false);
  84. const [channelsError, setChannelsError] = useState<string | null>(null);
  85. const [savedAccountsError, setSavedAccountsError] = useState<string | null>(null);
  86. const [walletBalance, setWalletBalance] = useState<number | null>(null);
  87. const [walletBalanceLoading, setWalletBalanceLoading] = useState(false);
  88. const [savedAccounts, setSavedAccounts] = useState<SavedWithdrawAccount[]>([]);
  89. const [bankOptions, setBankOptions] = useState<WithdrawBankOption[]>([]);
  90. const [selectedChannelId, setSelectedChannelId] = useState("");
  91. const [selectedSavedId, setSelectedSavedId] = useState("");
  92. const [selectedBankCode, setSelectedBankCode] = useState("");
  93. const [address, setAddress] = useState("");
  94. const [amount, setAmount] = useState("");
  95. const [agree, setAgree] = useState(false);
  96. const [agreeExtra, setAgreeExtra] = useState(false);
  97. const [agencyNo, setAgencyNo] = useState("");
  98. const [cpf, setCpf] = useState("");
  99. const [bankUnameInput, setBankUnameInput] = useState("");
  100. const [bankCardNumInput, setBankCardNumInput] = useState("");
  101. const [bankNameInput, setBankNameInput] = useState("");
  102. const [bankBranchNameInput, setBankBranchNameInput] = useState("");
  103. const [swiftCodeInput, setSwiftCodeInput] = useState("");
  104. const [customBankCodeInput, setCustomBankCodeInput] = useState("");
  105. const [bankAddrInput, setBankAddrInput] = useState("");
  106. const [telegraphicCurrency, setTelegraphicCurrency] = useState("USD");
  107. const [cardUnameInput, setCardUnameInput] = useState("");
  108. const [cardNumInput, setCardNumInput] = useState("");
  109. const [cardCvvInput, setCardCvvInput] = useState("");
  110. const [cardExpiryInput, setCardExpiryInput] = useState("");
  111. const [submitting, setSubmitting] = useState(false);
  112. const [confirmOpen, setConfirmOpen] = useState(false);
  113. const [expandedGroup, setExpandedGroup] = useState<string>("数字货币");
  114. const [applyDialogOpen, setApplyDialogOpen] = useState(false);
  115. const [resultDialog, setResultDialog] = useState<{
  116. open: boolean;
  117. status: "success" | "error";
  118. title: string;
  119. message: string;
  120. }>({
  121. open: false,
  122. status: "success",
  123. title: "",
  124. message: "",
  125. });
  126. const selectedChannel = useMemo(
  127. () => channels.find((item) => item.id === selectedChannelId) ?? null,
  128. [channels, selectedChannelId],
  129. );
  130. const selectedSavedAccount = useMemo(
  131. () => savedAccounts.find((item) => item.id === selectedSavedId) ?? null,
  132. [savedAccounts, selectedSavedId],
  133. );
  134. const filteredSavedAccounts = useMemo(() => {
  135. if (!selectedChannel) return [];
  136. const type = savedAccountType(selectedChannel.type);
  137. if (type === null) return [];
  138. return savedAccounts.filter((item) => item.type === type);
  139. }, [savedAccounts, selectedChannel]);
  140. const shouldRequireSavedAccount = Boolean(selectedChannel && needsSavedAccount(selectedChannel.type));
  141. const shouldShowSavedAccountSelector = shouldRequireSavedAccount && filteredSavedAccounts.length > 0;
  142. const isBankTelegraphic = selectedChannel?.type === "BANK_TELEGRAPHIC";
  143. const needCpf = selectedChannel?.code === "PAY_RETAILER_REMIT_PAY_KEY_BRW";
  144. function resetApplyForm() {
  145. setSelectedSavedId("");
  146. setSelectedBankCode("");
  147. setAddress("");
  148. setAmount("");
  149. setAgree(false);
  150. setAgreeExtra(false);
  151. setAgencyNo("");
  152. setCpf("");
  153. setBankUnameInput("");
  154. setBankCardNumInput("");
  155. setBankNameInput("");
  156. setBankBranchNameInput("");
  157. setSwiftCodeInput("");
  158. setCustomBankCodeInput("");
  159. setBankAddrInput("");
  160. setTelegraphicCurrency("USD");
  161. setCardUnameInput("");
  162. setCardNumInput("");
  163. setCardCvvInput("");
  164. setCardExpiryInput("");
  165. }
  166. useEffect(() => {
  167. let cancelled = false;
  168. async function loadBase() {
  169. setWalletBalanceLoading(true);
  170. setChannelsLoading(true);
  171. setChannelsError(null);
  172. try {
  173. const [balanceResult, channelsResult] = await Promise.all([
  174. fetchWalletBalance(),
  175. fetchWithdrawChannels(),
  176. ]);
  177. if (cancelled) return;
  178. setWalletBalance(balanceResult);
  179. setChannels(channelsResult);
  180. } catch (e) {
  181. if (cancelled) return;
  182. const err = e as Error;
  183. setChannelsError(err?.message || "提款通道加载失败");
  184. setChannels([]);
  185. setWalletBalance(null);
  186. } finally {
  187. if (!cancelled) {
  188. setWalletBalanceLoading(false);
  189. setChannelsLoading(false);
  190. }
  191. }
  192. }
  193. void loadBase();
  194. return () => {
  195. cancelled = true;
  196. };
  197. }, []);
  198. useEffect(() => {
  199. if (!applyDialogOpen) return;
  200. let cancelled = false;
  201. async function loadSavedAccounts() {
  202. setSavedAccountsError(null);
  203. try {
  204. const list = await fetchSavedWithdrawAccounts();
  205. if (cancelled) return;
  206. setSavedAccounts(list);
  207. } catch (e) {
  208. if (cancelled) return;
  209. const err = e as Error;
  210. setSavedAccountsError(err?.message || "收款信息加载失败");
  211. setSavedAccounts([]);
  212. }
  213. }
  214. void loadSavedAccounts();
  215. return () => {
  216. cancelled = true;
  217. };
  218. }, [applyDialogOpen]);
  219. useEffect(() => {
  220. if (!selectedChannel || !applyDialogOpen) {
  221. setBankOptions([]);
  222. setSelectedBankCode("");
  223. return;
  224. }
  225. const currentChannel = selectedChannel;
  226. setSelectedSavedId("");
  227. setAddress("");
  228. setAgree(false);
  229. setAgreeExtra(false);
  230. setBankUnameInput("");
  231. setBankCardNumInput("");
  232. setBankNameInput("");
  233. setBankBranchNameInput("");
  234. setSwiftCodeInput("");
  235. setCustomBankCodeInput("");
  236. setBankAddrInput("");
  237. setTelegraphicCurrency("USD");
  238. setCardUnameInput("");
  239. setCardNumInput("");
  240. setCardCvvInput("");
  241. setCardExpiryInput("");
  242. if (!currentChannel.bankValid) {
  243. setBankOptions([]);
  244. setSelectedBankCode("");
  245. return;
  246. }
  247. let cancelled = false;
  248. async function loadBankOptions() {
  249. try {
  250. const list = await fetchWithdrawBankOptions(currentChannel.code);
  251. if (cancelled) return;
  252. setBankOptions(list);
  253. setSelectedBankCode((prev) => prev || list[0]?.code || "");
  254. } catch {
  255. if (cancelled) return;
  256. setBankOptions([]);
  257. }
  258. }
  259. void loadBankOptions();
  260. return () => {
  261. cancelled = true;
  262. };
  263. }, [selectedChannel, applyDialogOpen]);
  264. useEffect(() => {
  265. if (!selectedSavedAccount) return;
  266. if (!isBankType(selectedChannel?.type || "") && !isBankTelegraphic) return;
  267. setBankUnameInput(selectedSavedAccount.bankUname || "");
  268. setBankCardNumInput(selectedSavedAccount.bankCardNum || "");
  269. setBankNameInput(selectedSavedAccount.bankName || "");
  270. setBankBranchNameInput(selectedSavedAccount.bankBranchName || "");
  271. setSwiftCodeInput(selectedSavedAccount.swiftCode || "");
  272. setCustomBankCodeInput(selectedSavedAccount.customBankCode || "");
  273. setBankAddrInput(selectedSavedAccount.bankAddr || "");
  274. }, [selectedSavedAccount, selectedChannel?.type, isBankTelegraphic]);
  275. function validate(): string | null {
  276. if (!selectedChannel) return "请选择提款通道";
  277. if (!/^[0-9]+([.][0-9]{1,2})?$/.test(amount.trim())) return "请输入正确的提款金额";
  278. const amountNum = Number(amount);
  279. if (!Number.isFinite(amountNum) || amountNum <= 0) return "提款金额必须大于 0";
  280. if (selectedChannel.minAmount > 0 && amountNum < selectedChannel.minAmount) {
  281. return `提款金额不能低于 ${selectedChannel.minAmount}`;
  282. }
  283. if (selectedChannel.maxAmount > 0 && amountNum > selectedChannel.maxAmount) {
  284. return `提款金额不能高于 ${selectedChannel.maxAmount}`;
  285. }
  286. if (isWalletType(selectedChannel.type) && !address.trim()) return "请填写提款地址";
  287. if (shouldRequireSavedAccount && filteredSavedAccounts.length === 0) {
  288. return "当前通道暂无可用收款信息,请更换通道或先补充收款信息";
  289. }
  290. if (shouldShowSavedAccountSelector && !selectedSavedId) return "请选择收款信息";
  291. if (isBankType(selectedChannel.type)) {
  292. if (!bankUnameInput.trim()) return "请输入户名";
  293. if (!bankCardNumInput.trim()) return "请输入银行卡号";
  294. if (!bankNameInput.trim()) return "请输入银行名称";
  295. if (!bankBranchNameInput.trim()) return "请输入支行名称";
  296. }
  297. if (isBankTelegraphic) {
  298. if (!bankUnameInput.trim()) return "请输入户名";
  299. if (!bankCardNumInput.trim()) return "请输入银行卡号";
  300. if (!bankNameInput.trim()) return "请输入银行名称";
  301. if (!swiftCodeInput.trim()) return "请输入Swift Code";
  302. if (!customBankCodeInput.trim()) return "请输入银行代码";
  303. if (!bankAddrInput.trim()) return "请输入银行地址";
  304. }
  305. if (isBankTelegraphic && !agencyNo.trim()) return "请填写 Account Agency NO";
  306. if (isBankTelegraphic && needCpf && !cpf.trim()) return "请填写 CPF";
  307. if (isCardType(selectedChannel.type)) {
  308. if (!cardUnameInput.trim()) return "请输入信用卡户名";
  309. if (!cardNumInput.trim()) return "请输入信用卡账户";
  310. if (!cardCvvInput.trim()) return "请输入CVV";
  311. if (!cardExpiryInput.trim()) return "请输入到期年份/月 份";
  312. }
  313. if (!agree) return "请先勾选并同意提款条款";
  314. if (!agreeExtra) return "请勾选第二条提款确认条款";
  315. return null;
  316. }
  317. async function doSubmit() {
  318. if (!selectedChannel) return;
  319. const amountNum = Number(amount);
  320. const payload: Record<string, unknown> = {
  321. payType: selectedChannel.code,
  322. amount: amountNum,
  323. currency: selectedChannel.type === "BANK_TELEGRAPHIC" ? "USD" : selectedChannel.currency,
  324. agree2: true,
  325. };
  326. if (selectedBankCode) payload.bankCode = selectedBankCode;
  327. if (address.trim()) payload.address = address.trim();
  328. if (selectedSavedAccount) {
  329. payload.id = selectedSavedAccount.id;
  330. payload.bankUname = selectedSavedAccount.bankUname;
  331. payload.bankCardNum = selectedSavedAccount.bankCardNum;
  332. payload.bankName = selectedSavedAccount.bankName;
  333. payload.bankBranchName = selectedSavedAccount.bankBranchName;
  334. payload.bankAddr = selectedSavedAccount.bankAddr;
  335. payload.swiftCode = selectedSavedAccount.swiftCode;
  336. payload.customBankCode = selectedSavedAccount.customBankCode;
  337. payload.addressName = selectedSavedAccount.addressName;
  338. payload.address = payload.address ?? selectedSavedAccount.address;
  339. payload.cvv = selectedSavedAccount.cvv;
  340. payload.expiryYearMonth = selectedSavedAccount.expiryYearMonth;
  341. }
  342. if (isBankType(selectedChannel.type)) {
  343. payload.bankUname = bankUnameInput.trim();
  344. payload.bankCardNum = bankCardNumInput.trim();
  345. payload.bankName = bankNameInput.trim();
  346. payload.bankBranchName = bankBranchNameInput.trim();
  347. }
  348. if (isBankTelegraphic) {
  349. payload.bankUname = bankUnameInput.trim();
  350. payload.bankCardNum = bankCardNumInput.trim();
  351. payload.bankName = bankNameInput.trim();
  352. payload.swiftCode = swiftCodeInput.trim();
  353. payload.customBankCode = customBankCodeInput.trim();
  354. payload.bankAddr = bankAddrInput.trim();
  355. payload.currency = telegraphicCurrency || "USD";
  356. payload.agencyNo = agencyNo.trim();
  357. if (needCpf) payload.cpf = cpf.trim();
  358. }
  359. if (isCardType(selectedChannel.type)) {
  360. payload.bankUname = cardUnameInput.trim();
  361. payload.bankCardNum = cardNumInput.trim();
  362. payload.cvv = cardCvvInput.trim();
  363. payload.expiryYearMonth = cardExpiryInput.trim();
  364. }
  365. setSubmitting(true);
  366. try {
  367. await submitWithdrawApply({
  368. requestUrl: selectedChannel.requestUrl,
  369. payload,
  370. });
  371. setResultDialog({
  372. open: true,
  373. status: "success",
  374. title: "提交成功",
  375. message: "提款申请已提交,请等待平台审核。",
  376. });
  377. resetApplyForm();
  378. } catch (e) {
  379. const err = e as Error;
  380. setResultDialog({
  381. open: true,
  382. status: "error",
  383. title: "提交失败",
  384. message: err.message || "提款申请失败,请稍后重试。",
  385. });
  386. } finally {
  387. setSubmitting(false);
  388. setConfirmOpen(false);
  389. }
  390. }
  391. const channelGroups = useMemo(() => {
  392. const groups: Record<string, WithdrawChannel[]> = {};
  393. for (const item of channels) {
  394. const key = channelGroupLabel(item);
  395. if (!groups[key]) groups[key] = [];
  396. groups[key].push(item);
  397. }
  398. return Object.entries(groups).sort((a, b) => groupOrder(a[0]) - groupOrder(b[0]));
  399. }, [channels]);
  400. return (
  401. <div className="page-shell page-shell-wide">
  402. <h1 className="font-serif text-2xl font-semibold text-[var(--navy)]">提款申请</h1>
  403. <p className="mt-2 text-sm text-[var(--muted)]">流程:选择通道 - 填写信息 - 确认提交</p>
  404. <section className="mt-6 rounded-2xl border border-[var(--border)] bg-[var(--card)] p-4">
  405. <h2 className="text-sm font-semibold text-[var(--navy)]">提款通道</h2>
  406. {channelsLoading ? <InlineLoading text="通道加载中..." className="mt-2" /> : null}
  407. {channelsError ? <p className="mt-2 text-sm text-rose-700">{channelsError}</p> : null}
  408. {!channelsLoading && !channelsError && channelGroups.length === 0 ? (
  409. <p className="mt-2 text-sm text-[var(--muted)]">暂无可用通道</p>
  410. ) : null}
  411. <div className="mt-3 space-y-3">
  412. {channelGroups.map(([group, items]) => (
  413. <div key={group} className="rounded-lg border border-[var(--border)] bg-white">
  414. <button
  415. type="button"
  416. onClick={() => setExpandedGroup((v) => (v === group ? "" : group))}
  417. className="flex w-full items-center gap-2 px-3 py-2 text-left text-base font-semibold text-[var(--navy)]"
  418. >
  419. <span
  420. className={`text-sm transition-transform duration-300 ${
  421. expandedGroup === group ? "rotate-0" : "-rotate-90"
  422. }`}
  423. >
  424. </span>
  425. <span>{group}</span>
  426. </button>
  427. <div
  428. className={`grid overflow-hidden transition-all duration-300 ease-out ${
  429. expandedGroup === group ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
  430. }`}
  431. >
  432. <div className="min-h-0">
  433. <div className="overflow-x-auto border-t border-[var(--border)]">
  434. <table className="w-full min-w-[900px] text-sm">
  435. <thead className="bg-slate-100/70 text-[var(--navy)]">
  436. <tr>
  437. <th className="px-3 py-2 text-left font-semibold">付款方式</th>
  438. <th className="px-3 py-2 text-left font-semibold">描述</th>
  439. <th className="px-3 py-2 text-left font-semibold">金额范围</th>
  440. <th className="px-3 py-2 text-left font-semibold">处理时间</th>
  441. <th className="px-3 py-2 text-left font-semibold">费用</th>
  442. <th className="px-3 py-2 text-right font-semibold">操作</th>
  443. </tr>
  444. </thead>
  445. <tbody>
  446. {items.map((item) => (
  447. <tr key={item.id} className="border-t border-[var(--border)]">
  448. <td className="px-3 py-2">
  449. <div className="flex items-center gap-2">
  450. {item.icon ? (
  451. // eslint-disable-next-line @next/next/no-img-element
  452. <img
  453. src={item.icon}
  454. alt={item.name || item.code}
  455. className="h-7 w-7 rounded object-contain"
  456. />
  457. ) : (
  458. <span className="inline-block h-7 w-7 rounded bg-slate-100 text-center leading-7">
  459. -
  460. </span>
  461. )}
  462. <span>{item.name || item.code}</span>
  463. </div>
  464. </td>
  465. <td className="px-3 py-2 text-[var(--muted)]">{item.enName || item.name || "-"}</td>
  466. <td className="px-3 py-2">{formatAmountRange(item)}</td>
  467. <td className="px-3 py-2">{item.fundingTime || "1 hours"}</td>
  468. <td className="px-3 py-2">{formatFee(item)}</td>
  469. <td className="px-3 py-2 text-right">
  470. <button
  471. type="button"
  472. onClick={() => {
  473. setSelectedChannelId(item.id);
  474. setApplyDialogOpen(true);
  475. }}
  476. className={`ui-interactive-btn rounded border px-4 py-1 text-xs font-semibold ${
  477. selectedChannelId === item.id
  478. ? "border-[var(--navy)] bg-[var(--navy)] text-white"
  479. : "border-[var(--border)] bg-white text-[var(--navy)] hover:bg-slate-50"
  480. }`}
  481. >
  482. 选择
  483. </button>
  484. </td>
  485. </tr>
  486. ))}
  487. </tbody>
  488. </table>
  489. </div>
  490. {items.length === 0 ? <p className="px-3 py-3 text-sm text-[var(--muted)]">暂无通道</p> : null}
  491. </div>
  492. </div>
  493. </div>
  494. ))}
  495. </div>
  496. {selectedChannel ? (
  497. <p className="mt-3 text-sm text-emerald-700">
  498. 已选择通道:{selectedChannel.name || selectedChannel.enName || selectedChannel.code}
  499. </p>
  500. ) : null}
  501. </section>
  502. <p className="mt-8 text-center text-sm">
  503. <Link href="/account" className="text-[var(--accent)] hover:underline">
  504. 返回用户中心
  505. </Link>
  506. </p>
  507. {selectedChannel ? (
  508. <ModalShell open={confirmOpen} className="max-w-md" zIndexClassName="z-[70]">
  509. <div className="w-full rounded-2xl border border-[var(--border)] bg-[var(--card)] p-5 shadow-xl">
  510. <p className="text-base font-semibold text-[var(--navy)]">确认提交提款申请?</p>
  511. <div className="mt-3 space-y-1 text-sm text-[var(--muted)]">
  512. <p>提款通道:{selectedChannel.name || selectedChannel.enName || selectedChannel.code}</p>
  513. <p>
  514. 提款金额:{amount} {selectedChannel.currency || "USD"}
  515. </p>
  516. </div>
  517. <div className="mt-5 flex justify-end gap-2">
  518. <button
  519. type="button"
  520. onClick={() => setConfirmOpen(false)}
  521. className="rounded-lg border border-[var(--border)] px-4 py-2 text-sm"
  522. >
  523. 取消
  524. </button>
  525. <button
  526. type="button"
  527. onClick={() => void doSubmit()}
  528. className="rounded-lg bg-[var(--navy)] px-4 py-2 text-sm text-white"
  529. >
  530. 确认提交
  531. </button>
  532. </div>
  533. </div>
  534. </ModalShell>
  535. ) : null}
  536. {selectedChannel ? (
  537. <ModalShell open={applyDialogOpen} className="max-w-2xl" zIndexClassName="z-[60]">
  538. <section
  539. className="w-full rounded-2xl border border-[var(--border)] bg-[var(--card)] p-4 shadow-xl"
  540. >
  541. <div className="flex items-center justify-between">
  542. <h2 className="text-sm font-semibold text-[var(--navy)]">填写提款信息</h2>
  543. <button
  544. type="button"
  545. onClick={() => {
  546. resetApplyForm();
  547. setApplyDialogOpen(false);
  548. }}
  549. className="ui-interactive-btn rounded border border-[var(--border)] px-2 py-1 text-xs text-[var(--muted)]"
  550. >
  551. 关闭
  552. </button>
  553. </div>
  554. <div className="mt-3 rounded-lg border border-[var(--border)] bg-slate-50 px-3 py-2 text-sm text-[var(--navy)]">
  555. 钱包余额:
  556. <span className="ml-1 font-semibold tabular-nums">
  557. {walletBalanceLoading ? "加载中..." : `$${(walletBalance ?? 0).toFixed(2)}`}
  558. </span>
  559. </div>
  560. {selectedChannel.introduce || selectedChannel.enIntroduce ? (
  561. <div
  562. className="mt-3 rounded-lg border border-[var(--border)] bg-slate-50 p-3 text-sm leading-7 text-[var(--navy)]"
  563. dangerouslySetInnerHTML={{
  564. __html: sanitizeHtml(selectedChannel.introduce || selectedChannel.enIntroduce || ""),
  565. }}
  566. />
  567. ) : null}
  568. {bankOptions.length > 0 ? (
  569. <div className="mt-3">
  570. <label className="text-sm font-medium text-[var(--navy)]">银行通道</label>
  571. <select
  572. value={selectedBankCode}
  573. onChange={(e) => setSelectedBankCode(e.target.value)}
  574. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  575. >
  576. <option value="">请选择银行通道</option>
  577. {bankOptions.map((item) => (
  578. <option key={item.code} value={item.code}>
  579. {item.name || item.enName || item.code}
  580. </option>
  581. ))}
  582. </select>
  583. </div>
  584. ) : null}
  585. {shouldShowSavedAccountSelector ? (
  586. <div className="mt-3">
  587. <label className="text-sm font-medium text-[var(--navy)]">收款信息</label>
  588. <select
  589. value={selectedSavedId}
  590. onChange={(e) => setSelectedSavedId(e.target.value)}
  591. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  592. >
  593. <option value="">请选择收款信息</option>
  594. {filteredSavedAccounts.map((item) => (
  595. <option key={item.id} value={item.id} disabled={item.type === 4 && item.authStatus === 0}>
  596. {item.type === 4
  597. ? `${item.addressName || "-"} - ${item.address || "-"}`
  598. : `${item.bankName || "-"} - ${item.bankCardNum || "-"}`}
  599. </option>
  600. ))}
  601. </select>
  602. </div>
  603. ) : null}
  604. {isBankType(selectedChannel.type) ? (
  605. <div className="mt-3 grid gap-3 md:grid-cols-2">
  606. <div>
  607. <label className="text-sm font-medium text-[var(--navy)]">户名</label>
  608. <input
  609. value={bankUnameInput}
  610. onChange={(e) => setBankUnameInput(e.target.value)}
  611. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  612. />
  613. </div>
  614. <div>
  615. <label className="text-sm font-medium text-[var(--navy)]">银行卡号</label>
  616. <input
  617. value={bankCardNumInput}
  618. onChange={(e) => setBankCardNumInput(e.target.value)}
  619. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  620. />
  621. </div>
  622. <div>
  623. <label className="text-sm font-medium text-[var(--navy)]">银行名称</label>
  624. <input
  625. value={bankNameInput}
  626. onChange={(e) => setBankNameInput(e.target.value)}
  627. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  628. />
  629. </div>
  630. <div>
  631. <label className="text-sm font-medium text-[var(--navy)]">支行名称</label>
  632. <input
  633. value={bankBranchNameInput}
  634. onChange={(e) => setBankBranchNameInput(e.target.value)}
  635. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  636. />
  637. </div>
  638. </div>
  639. ) : null}
  640. {selectedChannel.type === "BANK_TELEGRAPHIC" ? (
  641. <div className="mt-3 grid gap-3 md:grid-cols-3">
  642. <div>
  643. <label className="text-sm font-medium text-[var(--navy)]">户名</label>
  644. <input
  645. value={bankUnameInput}
  646. onChange={(e) => setBankUnameInput(e.target.value)}
  647. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  648. />
  649. </div>
  650. <div>
  651. <label className="text-sm font-medium text-[var(--navy)]">银行卡号</label>
  652. <input
  653. value={bankCardNumInput}
  654. onChange={(e) => setBankCardNumInput(e.target.value)}
  655. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  656. />
  657. </div>
  658. <div>
  659. <label className="text-sm font-medium text-[var(--navy)]">银行名称</label>
  660. <input
  661. value={bankNameInput}
  662. onChange={(e) => setBankNameInput(e.target.value)}
  663. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  664. />
  665. </div>
  666. <div>
  667. <label className="text-sm font-medium text-[var(--navy)]">Swift Code</label>
  668. <input
  669. value={swiftCodeInput}
  670. onChange={(e) => setSwiftCodeInput(e.target.value)}
  671. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  672. />
  673. </div>
  674. <div>
  675. <label className="text-sm font-medium text-[var(--navy)]">银行代码</label>
  676. <input
  677. value={customBankCodeInput}
  678. onChange={(e) => setCustomBankCodeInput(e.target.value)}
  679. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  680. />
  681. </div>
  682. <div>
  683. <label className="text-sm font-medium text-[var(--navy)]">银行地址</label>
  684. <input
  685. value={bankAddrInput}
  686. onChange={(e) => setBankAddrInput(e.target.value)}
  687. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  688. />
  689. </div>
  690. </div>
  691. ) : null}
  692. {isCardType(selectedChannel.type) ? (
  693. <div className="mt-3 grid gap-3 md:grid-cols-2">
  694. <div>
  695. <label className="text-sm font-medium text-[var(--navy)]">信用卡户名</label>
  696. <input
  697. value={cardUnameInput}
  698. onChange={(e) => setCardUnameInput(e.target.value)}
  699. placeholder="John Doe"
  700. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  701. />
  702. </div>
  703. <div>
  704. <label className="text-sm font-medium text-[var(--navy)]">信用卡账户</label>
  705. <input
  706. value={cardNumInput}
  707. onChange={(e) => setCardNumInput(e.target.value)}
  708. placeholder="5188 5136 1855 2975"
  709. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  710. />
  711. </div>
  712. <div>
  713. <label className="text-sm font-medium text-[var(--navy)]">CVV</label>
  714. <input
  715. value={cardCvvInput}
  716. onChange={(e) => setCardCvvInput(e.target.value)}
  717. placeholder="123"
  718. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  719. />
  720. </div>
  721. <div>
  722. <label className="text-sm font-medium text-[var(--navy)]">到期年份/月 份</label>
  723. <input
  724. value={cardExpiryInput}
  725. onChange={(e) => setCardExpiryInput(e.target.value)}
  726. placeholder="30/09"
  727. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  728. />
  729. </div>
  730. </div>
  731. ) : null}
  732. {shouldRequireSavedAccount && !shouldShowSavedAccountSelector ? (
  733. <p className="mt-3 text-xs text-[var(--muted)]">
  734. 当前通道暂无可用收款信息,请更换通道或先补充收款信息。
  735. </p>
  736. ) : null}
  737. {savedAccountsError && shouldRequireSavedAccount ? (
  738. <p className="mt-1 text-xs text-[var(--muted)]">
  739. 收款信息加载失败,可先切换其他通道后重试。
  740. </p>
  741. ) : null}
  742. {isWalletType(selectedChannel.type) ? (
  743. <div className="mt-3">
  744. <label className="text-sm font-medium text-[var(--navy)]">提款地址</label>
  745. <input
  746. value={address}
  747. onChange={(e) => setAddress(e.target.value)}
  748. className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
  749. />
  750. </div>
  751. ) : null}
  752. {isBankTelegraphic ? (
  753. <div className="mt-3 grid gap-3 md:grid-cols-2">
  754. <div>
  755. <label className="text-sm font-medium text-[var(--navy)]">货币类型</label>
  756. <select
  757. value={telegraphicCurrency}
  758. onChange={(e) => setTelegraphicCurrency(e.target.value)}
  759. className="mt-1 w-full rounded-lg border border-[var(--border)] bg-white px-3 py-2 text-sm"
  760. >
  761. <option value="USD">USD</option>
  762. </select>
  763. </div>
  764. <div>
  765. <label className="text-sm font-medium text-[var(--navy)]">金额</label>
  766. <input
  767. value={amount}
  768. onChange={(e) => setAmount(e.target.value)}
  769. className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
  770. />
  771. </div>
  772. </div>
  773. ) : (
  774. <div className="mt-3">
  775. <label className="text-sm font-medium text-[var(--navy)]">
  776. 提款金额({selectedChannel.currency || "USD"})
  777. </label>
  778. <input
  779. value={amount}
  780. onChange={(e) => setAmount(e.target.value)}
  781. className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
  782. />
  783. </div>
  784. )}
  785. {isBankTelegraphic ? (
  786. <div className="mt-3 grid gap-3 md:grid-cols-2">
  787. <div>
  788. <label className="text-sm font-medium text-[var(--navy)]">Account Agency NO</label>
  789. <input
  790. value={agencyNo}
  791. onChange={(e) => setAgencyNo(e.target.value)}
  792. className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
  793. />
  794. </div>
  795. {needCpf ? (
  796. <div>
  797. <label className="text-sm font-medium text-[var(--navy)]">CPF</label>
  798. <input
  799. value={cpf}
  800. onChange={(e) => setCpf(e.target.value)}
  801. className="mt-1 w-full rounded-lg border border-[var(--border)] px-3 py-2 text-sm"
  802. />
  803. </div>
  804. ) : null}
  805. </div>
  806. ) : null}
  807. {
  808. <label className="mt-3 flex items-start gap-2 text-sm text-[var(--navy)]">
  809. <input
  810. type="checkbox"
  811. checked={agree}
  812. onChange={(e) => setAgree(e.target.checked)}
  813. className="mt-0.5"
  814. />
  815. <span>我已阅读并同意提款条款,知悉手续费与到账时间以平台审核为准。</span>
  816. </label>
  817. }
  818. {
  819. <label className="mt-2 flex items-start gap-2 text-sm text-[var(--navy)]">
  820. <input
  821. type="checkbox"
  822. checked={agreeExtra}
  823. onChange={(e) => setAgreeExtra(e.target.checked)}
  824. className="mt-0.5"
  825. />
  826. <span>* 我确认本次提款信息准确无误,并接受平台审核结果。</span>
  827. </label>
  828. }
  829. {
  830. <button
  831. type="button"
  832. disabled={submitting}
  833. onClick={() => {
  834. const msg = validate();
  835. if (msg) {
  836. setResultDialog({
  837. open: true,
  838. status: "error",
  839. title: "请检查输入信息",
  840. message: msg,
  841. });
  842. return;
  843. }
  844. setConfirmOpen(true);
  845. }}
  846. className="ui-interactive-btn mt-4 w-full rounded-full bg-[var(--navy)] py-3 text-sm font-semibold text-white disabled:opacity-60"
  847. >
  848. {submitting ? "提交中..." : "提交提款申请"}
  849. </button>
  850. }
  851. </section>
  852. </ModalShell>
  853. ) : null}
  854. <ModalShell open={resultDialog.open} className="max-w-md" zIndexClassName="z-[80]">
  855. <div className="w-full overflow-hidden rounded-2xl border border-[var(--border)] bg-[var(--card)] shadow-2xl">
  856. <div
  857. className={`px-5 py-4 ${
  858. resultDialog.status === "success"
  859. ? "bg-gradient-to-r from-emerald-500/10 to-emerald-400/5"
  860. : "bg-gradient-to-r from-rose-500/10 to-rose-400/5"
  861. }`}
  862. >
  863. <div className="flex items-center gap-3">
  864. <span
  865. className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-base font-bold ${
  866. resultDialog.status === "success"
  867. ? "bg-emerald-100 text-emerald-700"
  868. : "bg-rose-100 text-rose-700"
  869. }`}
  870. >
  871. {resultDialog.status === "success" ? "✓" : "!"}
  872. </span>
  873. <p className="text-base font-semibold text-[var(--navy)]">{resultDialog.title}</p>
  874. </div>
  875. </div>
  876. <div className="px-5 py-4">
  877. <p className="text-sm leading-6 text-[var(--muted)]">{resultDialog.message}</p>
  878. <button
  879. type="button"
  880. onClick={() => setResultDialog((prev) => ({ ...prev, open: false }))}
  881. className={`ui-interactive-btn mt-5 w-full rounded-full py-2.5 text-sm font-semibold text-white ${
  882. resultDialog.status === "success"
  883. ? "bg-emerald-600 hover:bg-emerald-700"
  884. : "bg-[var(--navy)] hover:bg-[var(--navy-soft)]"
  885. }`}
  886. >
  887. 我知道了
  888. </button>
  889. </div>
  890. </div>
  891. </ModalShell>
  892. </div>
  893. );
  894. }